1"use client";
2
3
18import { useState } from "react";
19import { useParams } from "next/navigation";
20import Link from "next/link";
21import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22import { CheckSquare, Circle, CircleDashed, Flag, Loader2, Plus, XCircle } from "lucide-react";
23import { formatRelativeTime } from "@/lib/utils";
24import { taskApi } from "@/lib/api";
25import { useWorkspace } from "@/hooks/use-workspace";
26import type { Task } from "@/lib/types";
27
28const TABS = [
29 { id: "mine", label: "Mine" },
30 { id: "due", label: "Due soon" },
31 { id: "overdue", label: "Overdue" },
32 { id: "done", label: "Done" },
33 { id: "all", label: "All" }
34] as const;
35type TabId = (typeof TABS)[number]["id"];
36
37
39function tabParams(tab: TabId): Parameters<typeof taskApi.list>[1] {
40 switch (tab) {
41 case "due":
42 return { scope: "inbox", limit: 100, dueBefore: new Date(Date.now() + 7 * 86_400_000).toISOString() };
43 case "overdue":
44 return { scope: "inbox", limit: 100, overdue: true };
45 case "done":
46 return { scope: "inbox", limit: 100, status: "done" };
47 case "all":
48 return { scope: "all", limit: 100 };
49 default:
50 return { scope: "inbox", limit: 100 };
51 }
52}
53
54const STATUS_META: Record<Task["status"], { label: string; Icon: typeof Circle; cls: string }> = {
55 todo: { label: "To do", Icon: Circle, cls: "text-slate-500" },
56 in_progress: { label: "In progress", Icon: CircleDashed, cls: "text-amber-600" },
57 done: { label: "Done", Icon: CheckSquare, cls: "text-emerald-600" },
58 cancelled: { label: "Cancelled", Icon: XCircle, cls: "text-rose-500" }
59};
60
61const NEXT: Record<Task["status"], Task["status"] | null> = {
62 todo: "in_progress",
63 in_progress: "done",
64 done: null,
65 cancelled: "todo"
66};
67
68export default function InboxPage() {
69 const params = useParams<{ workspace: string }>();
70 const slugOrId = params?.workspace ?? "";
71 const { data: workspace } = useWorkspace(slugOrId);
72 const wid = workspace?.id ?? slugOrId;
73 const qc = useQueryClient();
74 const [tab, setTab] = useState<TabId>("mine");
75 const [creating, setCreating] = useState(false);
76 const [newTitle, setNewTitle] = useState("");
77
78 const list = useQuery({
79 queryKey: ["tasks", wid, tab],
80 queryFn: () => taskApi.list(wid, tabParams(tab)),
81 enabled: !!workspace?.id,
82 retry: false
83 });
84
85 const invalidate = () => {
86 for (const t of TABS) qc.invalidateQueries({ queryKey: ["tasks", wid, t.id] });
87 };
88
89 const create = useMutation({
90 mutationFn: (title: string) => taskApi.create(wid, { title }),
91 onSuccess: () => {
92 setCreating(false);
93 setNewTitle("");
94 invalidate();
95 }
96 });
97
98 const setStatus = useMutation({
99 mutationFn: (t: Task) => taskApi.update(wid, t.id, { status: NEXT[t.status]! }),
100 onSuccess: invalidate
101 });
102
103 const remove = useMutation({
104 mutationFn: (id: string) => taskApi.remove(wid, id),
105 onSuccess: invalidate
106 });
107
108 const items = list.data?.data ?? [];
109
110 return (
111 <div className="mx-auto max-w-3xl px-4 py-8">
112 <div className="mb-4 flex items-center justify-between gap-3">
113 <h1 className="text-xl font-semibold">Inbox</h1>
114 {creating ? (
115 <form
116 onSubmit={(e) => {
117 e.preventDefault();
118 if (newTitle.trim()) create.mutate(newTitle.trim());
119 }}
120 className="flex items-center gap-2"
121 >
122 <input
123 autoFocus
124 value={newTitle}
125 onChange={(e) => setNewTitle(e.target.value)}
126 placeholder="Task title"
127 className="w-56 rounded-md border border-slate-300 px-2 py-1 text-sm"
128 />
129 <button type="submit" className="rounded-md bg-slate-900 px-2 py-1 text-sm text-white" aria-label="Add task">
130 {create.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
131 </button>
132 <button
133 type="button"
134 onClick={() => setCreating(false)}
135 className="rounded-md border border-slate-300 px-2 py-1 text-sm"
136 aria-label="Cancel"
137 >
138 Cancel
139 </button>
140 </form>
141 ) : (
142 <button
143 onClick={() => setCreating(true)}
144 className="flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-sm hover:bg-slate-50"
145 >
146 <Plus className="h-4 w-4" /> New task
147 </button>
148 )}
149 </div>
150
151 <nav className="mb-4 flex gap-1 border-b border-slate-200" aria-label="Inbox filters">
152 {TABS.map((t) => (
153 <button
154 key={t.id}
155 onClick={() => setTab(t.id)}
156 className={`-mb-px border-b-2 px-3 py-2 text-sm ${
157 tab === t.id ? "border-slate-900 font-medium text-slate-900" : "border-transparent text-slate-500 hover:text-slate-800"
158 }`}
159 >
160 {t.label}
161 </button>
162 ))}
163 </nav>
164
165 {list.isLoading ? (
166 <div className="flex justify-center py-16">
167 <Loader2 className="h-5 w-5 animate-spin text-slate-400" />
168 </div>
169 ) : items.length === 0 ? (
170 <div className="flex flex-col items-center gap-2 py-16 text-center text-slate-500">
171 <Flag className="h-6 w-6" />
172 <p className="text-sm">No tasks here.</p>
173 </div>
174 ) : (
175 <ul className="divide-y divide-slate-100">
176 {items.map((t) => {
177 const meta = STATUS_META[t.status];
178 const overdue = t.dueAt && new Date(t.dueAt) < new Date() && t.status !== "done" && t.status !== "cancelled";
179 return (
180 <li key={t.id} className="flex items-center gap-3 py-2.5">
181 {NEXT[t.status] ? (
182 <button
183 onClick={() => setStatus.mutate(t)}
184 disabled={setStatus.isPending}
185 title={`Mark ${NEXT[t.status]}`}
186 className={`shrink-0 ${meta.cls}`}
187 aria-label={`Mark ${t.title} as ${NEXT[t.status]}`}
188 >
189 <meta.Icon className="h-5 w-5" />
190 </button>
191 ) : (
192 <meta.Icon className={`h-5 w-5 shrink-0 ${meta.cls}`} />
193 )}
194 <div className="min-w-0 flex-1">
195 <Link
196 href={`/w/${workspace?.id ?? slugOrId}/page/${t.pageId}`}
197 className="block truncate text-sm font-medium text-slate-900 hover:underline"
198 >
199 {t.pageTitle ?? t.title}
200 </Link>
201 {t.dueAt ? (
202 <span className={`text-xs ${overdue ? "text-rose-600" : "text-slate-500"}`}>
203 {overdue ? "Overdue — " : "Due "}
204 {formatRelativeTime(t.dueAt)}
205 </span>
206 ) : null}
207 </div>
208 <button
209 onClick={() => {
210 if (confirm(`Delete “${t.title}”? The task page moves to Trash.`)) remove.mutate(t.id);
211 }}
212 className="shrink-0 rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
213 aria-label={`Delete ${t.title}`}
214 >
215 <XCircle className="h-4 w-4" />
216 </button>
217 </li>
218 );
219 })}
220 </ul>
221 )}
222 </div>
223 );
224}
225